import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';

import { Observable, Subject, throwError } from 'rxjs';
import { catchError, filter, finalize, flatMap, switchMap, take } from 'rxjs/operators';

import { GeneralConfig } from '../config/general.config';
import { HttpMethodConstant } from '../constants/http-method.constant';
import { HttpStatusConstant } from '../constants/http-status.constant';
import { ServerCodeConstant } from '../constants/server-code.constant';
import { AuthenticationService } from './authentication.service';
import { CommonService } from './common.service';
import { MessageService } from './message.service';
import { PendingHttpRequestService } from './pending-http-request.service';
import { TokenService } from './token.service';

/**
 * HttpClient interaction. Did not use HttpInterceptor because I want to keep biz service loosely coupled with HttpClient.
 * If breaking update happens in the future due to HttpClient replacement. I can migrate smoothly by providing another 
 * HttpRequestService without touching any other biz service.
 */
@Injectable()
export class HttpRequestService {
    private readonly baseUrl: string;
    private readonly noTokenRefreshNeedUrls: string[];

    // To make sure these properties can be shared by multi-request please inject [HttpRequestService] in the [CoreModule].
    private isRefreshingToken: boolean;
    private tokenSubject: Subject<any>;

    constructor(
        private authenticationService: AuthenticationService,
        private commonService: CommonService,
        private httpClient: HttpClient,
        private message: MessageService,
        private pendingHttpRequestService: PendingHttpRequestService,
        private router: Router,
        private tokenService: TokenService
    ) {
        this.baseUrl = GeneralConfig.httpBaseUrl;
        this.isRefreshingToken = false;
        this.tokenSubject = new Subject<any>();

        this.noTokenRefreshNeedUrls = [
            '.json',
            'login'
        ];

        // The handleError will be invoked in catchError.
        // Just make sure the 'this' context is current class.
        this.filterCustomizedUnsuccessfulResponse = this.filterCustomizedUnsuccessfulResponse.bind(this);
        this.handleError = this.handleError.bind(this);
        this.handleCompletion = this.handleCompletion.bind(this);
    }

    /**
     * Example for making multiple requests.
     */
    makeMultipleRequests(): Observable<any> {
        // Example for make multiple requests that each one depends on previous result.
        return this.get('/auth/action1', {
            code: 'action1-code'
        }).pipe(
            // Transform the items emitted by an Observable into Observables, 
            // then flatten the emissions from those into a single Observable
            flatMap((res: any) => this.get('/auth/action2', {
                code: res.result.newCode
            })),
            flatMap((res: any) => this.get('/auth/action3', {
                code: res.result.newCode
            }))
        );

        // Example for make multiple requests that last one depends on previous results.
        // let code1$ = this.get('/auth/action1', { code: 'code1' });
        // let code2$ = this.get('/auth/action2', { code: 'code2' });

        // return forkJoin([code1$, code2$]).pipe(
        //     flatMap((res: any) => {
        //         return this.get('/auth/action3', {
        //             code1: res[0].result.newCode,
        //             code2: res[1].result.newCode
        //         })
        //     })
        // );
    }

    /**
     * Gets the system build-in HttpClient. For biz service complicated usage purpose.
     */
    getHttpClient(): HttpClient {
        return this.httpClient;
    }

    /**
     * Combines the provided base url and relative url. 
     * Does NOT use HttpInterceptor to achieve this goal because we want to 
     * make the code logic clear and in one place.
     * @param baseUrl The base url for current http request.
     * @param url The relative url string.
     * @returns The full url. 
     */
    combineUrl(baseUrl: string, url: string): string {
        if (baseUrl && baseUrl !== '') {
            // Converts the input url to lower case just in case.
            // url = url.toLowerCase();

            // Checks if the input url already has a leading slash.
            if (!url.startsWith('/')) {
                // Appends the leading slash if needed. 
                url = '/' + url;
            }

            // Checks if the input url already starts with the base url.
            if (url.startsWith(baseUrl)) {
                // If already contains the base url.
                return url;
            } else {
                // Appends the base url if needed.
                return baseUrl + url;
            }
        } else {
            return url;
        }
    }

    /**
     * Combines the relative url with default base url. 
     * @param url The relative url string.
     * @returns The full url. 
     */
    combineUrlWithDefault(url: string): string {
        return this.combineUrl(this.baseUrl, url);
    }

    /**
     * Creates the http request header for server authorization usage.
     * @returns Headers instance with authorization info. 
     */
    createAuthorizationRequestHeader(): HttpHeaders {
        let headers = new HttpHeaders();

        if (this.tokenService.hasAccessToken) {
            headers = headers.append('Authorization', `Bearer ${this.tokenService.activeAccessToken}`);
        }

        return headers;
    }

    /**
     * Creates the customized http request header.
     * @returns Headers instance with customized data. 
     */
    createRequestHeader(): HttpHeaders {
        // Sets headers here e.g.
        let headers = this.createAuthorizationRequestHeader();
        headers = headers.append('Content-Type', 'application/json');
        headers = headers.append('Accept', 'application/json');

        // Appends the database source id if the data exists.
        let databaseSource = this.commonService.loadSessionData('databaseSource');

        if (databaseSource) {
            headers = headers.append('Database-Source-Id', databaseSource.id.toString());
        }

        return headers;
    }

    /**
     * Prepares the HttpParams based on the provided parameter object.
     * @param params The provided parameter object.
     * @returns HttpParams instance.
     */
    prepareHttpParams(params: any = {}): HttpParams {
        // Prepares the HttpParams.
        let inputParams = new HttpParams();

        if (params) {
            for (let key in params) {
                inputParams = inputParams.set(key, params[key]);
            }
        }

        return inputParams;
    }

    /**
     * Filters out the custmized unsuccessful response.
     * @param res The http reponse.
     */
    filterCustomizedUnsuccessfulResponse(res: any): boolean {
        // Check if the response is not null and it contains the property called success.
        // If the response contains 'success' property the result will be filtered.
        if (res && !this.commonService.isNullOrUndefined(res.success)) {
            // If server reports unsuccess then the extra action will be taken.
            if (!res.success) {
                this.handleCustomizedServerCode(res);
            }

            // Filters out the request which server reports success.
            // That means those response with 'success == false' will not be pass through to any subscription.
            return res.success;
        } else {
            // If the response does NOT contains 'success' property the result will NOT be filtered.
            // The call for loading local json file or from third party might contains such property.
            return true;
        }
    }

    /**
     * Handles the start just before http request.
     */
    handleStart(): void {
        this.pendingHttpRequestService.increaseRequestCount();
    }

    /**
     * Handles Http operation that failed.
     * Lets the app continue.
     * @param operation - name of the operation that failed.
     * @param result - optional value to return as the observable result.
     */
    handleError(error: HttpErrorResponse): Observable<never> {
        if (error.error instanceof ErrorEvent) {
            // A client-side or network error occurred. Handle it accordingly.
            console.error('An error occurred:', error.error.message);
        } else {
            // The backend returned an unsuccessful http status code.
            // The response body may contain clues as to what went wrong.
            switch (error.status) {
                case HttpStatusConstant.unauthorized:
                    this.handleUnauthorizedError();
                    break;
                default:
                    this.message.showError('与后端服务通信发生未知错误，请与系统管理员联系');
                    break;
            }
        }

        // Returns an observable with a user-facing error message
        return throwError('与后端服务通信发生未知错误，请与系统管理员联系');
    }

    /**
     * Handles the http request completion.
     */
    handleCompletion(): void {
        // Decreases pending http request count when request finishes. This for hiding loading icon.
        this.pendingHttpRequestService.decreaseRequestCount();
    }

    /**
     * Handles customized server code when the http request itself is successful.
     */
    handleCustomizedServerCode(res: any): void {
        // The backend returned an unsuccessful code.
        switch (res.code) {
            case ServerCodeConstant.unauthorized:
                this.handleUnauthorizedError();
                break;
            default:
                this.message.showError('系统返回了未处理的自定义信息，请与系统管理员联系');
                break;
        }
    }

    /**
     * Checks on the token and uploads files to the server.
     * @param files The array of File instances.
     * @param url : The remote server relative url. The base url will be appended automatically. 
     */
    upload(files: File[], url: string, params: any = {}): Observable<any> {
        // IMPORTANT!!!
        // Here trying to access a protected url will trigger the auto refresh token process.
        // This will make upload operation work with valid access token at anytime.
        this.get(GeneralConfig.tokenCheckUrl).subscribe();

        if (!this.isRefreshingToken) {
            return this.startUpload(files, url, params);
        } else {
            // If token is refresing then current request has to wait until the new one is ready.
            // Once the new token is ready all pengding requests will be notified via tokenSubject.
            // filter, only take non-null value, that means the new token is ready.
            // take, Only take 1 here. This is important. This will sure below action will not be triggered multiple times.
            // Without take(1) the action will be triggered even after the code running. It keeps listening.
            return this.tokenSubject.pipe(
                filter(token => !this.commonService.isNullOrUndefinedOrEmptyString(token)),
                take(1),
                switchMap(p => this.startUpload(files, url, params))
            );
        }
    }

    /**
     * Gets the remote server data.
     * @param url The remote server relative url. The base url will be appended automatically.
     * @param params The parameters for getting data.
     * @returns The Observable result returned by HttpClient.
     */
    // get<T>(url: string, params: any = {}): Observable<T> {
    //     return this.makeGetRequest<T>(this.baseUrl, url, params);
    // }
    get<T>(url: string, params: any = {}): Observable<T> {
        return this.processHttpRequestWithTokenCheck<T>(HttpMethodConstant.get, this.baseUrl, url, params);
    }

    /**
     * Gets the remote server data without auto appended base url. It uses the provided url directly.
     * This is suitable for loading local json file or making a request to another remote system url.
     * @param url The remote server relative url. NO base url will be appended automatically.
     * @param params The parameters for getting data.
     * @returns The Observable result returned by HttpClient.
     */
    // getWithoutBaseUrl<T>(url: string, params: any = {}): Observable<T> {
    //     return this.makeGetRequest<T>(null, url, params);
    // }
    getWithoutBaseUrl<T>(url: string, params: any = {}): Observable<T> {
        return this.processHttpRequestWithTokenCheck<T>(HttpMethodConstant.get, null, url, params);
    }

    /**
     * Posts the data to the remote server.
     * @param url The remote server relative url. The base url will be appended automatically.
     * @param body The body data for posting data.
     * @returns The Observable result returned by HttpClient.
     */
    // post<T>(url: string, body: any = {}): Observable<T> {
    //     return this.makePostRequest<T>(this.baseUrl, url, body);
    // }
    post<T>(url: string, body: any = {}): Observable<T> {
        return this.processHttpRequestWithTokenCheck<T>(HttpMethodConstant.post, this.baseUrl, url, body);
    }

    /**
     * Uploads files to the server.
     * @param files The array of File instances.
     * @param url : The remote server relative url. The base url will be appended automatically. 
     */
    private startUpload(files: File[], url: string, params: any = {}): Observable<any> {
        const formData = new FormData();

        let inputParams = this.prepareHttpParams(params);

        // IMPORTANT: The value of first parameter of formData.append method must be the same as grails request.getFiles parameter.
        files.forEach(file => {
            formData.append('files', file, file.name);
        });

        // Prepares the request.
        const req = new HttpRequest('POST', this.combineUrlWithDefault(url), formData, {
            headers: this.createAuthorizationRequestHeader(),
            reportProgress: true,
            params: inputParams
        });

        return this.httpClient.request(req);
    }

    /**
     * Check if need to refresh the token based on the current requesting url.
     * @param url The string of current requesting url.
     * @returns True means need to refresh the token. False means NO such need.
     */
    private needToRefreshToken(url: string): boolean {
        return this.noTokenRefreshNeedUrls.filter(p => url.endsWith(p)).length === 0;
    }

    /**
     * Processes the http request by attaching it with other operators. This method will check the local token.
     * If the local access token expired the refresh token request will be raised before the user request.
     * Once the new access token is retrieved the user request will contiune.
     * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
     * @param baseUrl The string of http request base url.
     * @param url The string of current requesting url.
     * @param data The params for http get and body for http post.
     * @returns The same Observable result with HttpClient.
     */
    private processHttpRequestWithTokenCheck<T>(httpMethod: string, baseUrl: string, url: string, data: any): Observable<T> {
        // Checks if current has the token and if the access is exipred.
        // Also checks if current requesting url does not need to refresh the token, such like login.
        if (this.tokenService.hasAccessToken && this.tokenService.hasRefreshToken &&
            this.tokenService.isAccessTokenExpired && this.needToRefreshToken(url)) {
            if (!this.isRefreshingToken) {
                this.isRefreshingToken = true;

                // Saves current time which will be used to calculate how many seconds passed till token retrieved from remote server.
                // When calcuates on local we need to consider the time consumed by the remote request.
                let beforeTokenRetrieved = this.commonService.getCurrentDate();

                // Raises the request to refresh the token.
                // This is using httpClient directly. Then we have attach the catchError and finalize for it.
                // For others we have the catchError and finalize process handled by processHttpRequest.
                return this.httpClient.post(this.combineUrlWithDefault('/oauth/access_token'), {
                    grant_type: 'refresh_token',
                    refresh_token: this.tokenService.activeRefreshToken
                }).pipe(
                    flatMap((res: any) => {
                        // Calculates the seconds consumed by remote request.
                        let consumedSeconds = (this.commonService.getCurrentDate().getTime() - beforeTokenRetrieved.getTime()) / 1000;

                        // Merges the request_consumed_seconds with the data from remote server.
                        // flatMap will be called after token refresh finishes.
                        // Sets the new token retrieved from remote server.
                        this.tokenService.saveToken(Object.assign(res, { request_consumed_seconds: consumedSeconds }));

                        this.tokenSubject.next(res.access_token);

                        // Contiunes the original request.
                        // return this.processHttpRequest(httpMethod, baseUrl, url, data);
                        return this.makeRequest<T>(httpMethod, baseUrl, url, data);
                    }),
                    catchError(this.handleError),
                    finalize(() => {
                        this.isRefreshingToken = false;
                    })
                );
            } else {
                // If token is refresing then current request has to wait until the new one is ready.
                // Once the new token is ready all pengding requests will be notified via tokenSubject.
                // filter, only take non-null value, that means the new token is ready.
                // take, Only take 1 here. This is important. This will sure below action will not be triggered multiple times.
                // Without take(1) the action will be triggered even after the code running. It keeps listening.
                return this.tokenSubject.pipe(
                    filter(token => !this.commonService.isNullOrUndefinedOrEmptyString(token)),
                    take(1),
                    switchMap(p => {
                        // return this.processHttpRequest(httpMethod, baseUrl, url, data);
                        return this.makeRequest<T>(httpMethod, baseUrl, url, data);
                    })
                );
            }
        } else {
            // If no need to refresh the token contiunes the original request.
            // return this.processHttpRequest(httpMethod, baseUrl, url, data);
            return this.makeRequest<T>(httpMethod, baseUrl, url, data);
        }
    }

    /**
     * Makes the http request.
     * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
     * @param baseUrl The string of http request base url.
     * @param url The string of current requesting url.
     * @param data The params for http get and body for http post.
     * @returns The same Observable result with HttpClient.
     */
    private makeRequest<T>(httpMethod: string, baseUrl: string, url: string, data: any = {}): Observable<T> {
        if (httpMethod === HttpMethodConstant.get) {
            return this.makeGetRequest(baseUrl, url, data);
        } else if (httpMethod === HttpMethodConstant.post) {
            return this.makePostRequest(baseUrl, url, data);
        }
    }

    /**
     * Makes a http get request to remote server by using the provided url. The base url will be auto appended if needed.
     * @param url The remote server relative url. The base url will be appended automatically if needed.
     * @param params The parameters for getting data.
     * @returns The Observable result returned by HttpClient.
     */
    private makeGetRequest<T>(baseUrl: string, url: string, params: any = {}): Observable<T> {
        // Prepares the HttpParams.
        let inputParams = this.prepareHttpParams(params);

        // Appends the base url if needed.
        url = this.combineUrl(baseUrl, url);

        return this.processHttpRequest<T>(this.httpClient.get<T>(url, {
            headers: this.createRequestHeader(),
            params: inputParams
        }));
    }

    /**
     * Makes a http get request to remote server by using the provided url. The base url will be auto appended if needed.
     * @param url The remote server relative url. The base url will be appended automatically if needed.
     * @param params The parameters for getting data.
     * @returns The Observable result returned by HttpClient.
     */
    private makePostRequest<T>(baseUrl: string, url: string, body: any = {}): Observable<T> {
        // Appends the base url if needed.
        url = this.combineUrl(baseUrl, url);

        return this.processHttpRequest<T>(this.httpClient.post<T>(url, body, {
            headers: this.createRequestHeader()
        }));
    }

    /**
     * Processes the http request by attaching it with other operators.
     * @param result The Observable result returned by HttpClient.
     * @returns The same Observable result with HttpClient.
     */
    private processHttpRequest<T>(result: Observable<T>): Observable<T> {
        this.handleStart();

        // RxJS tap operator, which looks at the observable values, does something with those values, 
        // and passes them along. The tap call back doesn't touch the values themselves.
        // Decreases the pending http request count when request starts. This is for display loading icon.
        return result.pipe(
            filter(this.filterCustomizedUnsuccessfulResponse),
            catchError(this.handleError),
            finalize(this.handleCompletion)
        );
    }

    /**
     * Handles the unauthorized error returned by server.
     * User will be logged out if it's not logged out yet on client.
     * Just in case it might be triggered by several async requests multiple times.
     */
    private handleUnauthorizedError(): void {
        if (this.authenticationService.currentAuthenticationState) {
            this.authenticationService.removeUserInfo();
            this.authenticationService.redirectUrl = this.router.url;
            this.router.navigate(['/authentication/login']);
            this.message.showInfo('请验证您的身份');
        }
    }
}